spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { DecaysByMonth, DeploymentTimeline, MembershipNote, OrbitalShells, StatusDonut } from '@/components/entities/constellation-panels';5import { Block, EventsList, ExternalLink, HeroFacts, KpiStrip, PlannedUnavailable, Tag, entityMetadata } from '@/components/entities/shared';6import { LaunchesTable, SatellitesTable, SitesTable } from '@/components/entities/tables';7import { OrbitBadge } from '@/components/ui/badges';8import { Container } from '@/components/ui/section';9import { Unavailable } from '@/components/ui/unavailable';10import { ApiError, api } from '@/lib/api';11import { fmt1, fmtDate, fmtDateTime, fmtInt, fmtPct, num, titleCase } from '@/lib/format';12import { SITE_URL, routes } from '@/lib/site';13import type { ConstellationDetail } from '@/lib/types';1415type Props = { params: Promise<{ slug: string }> };1617async function load(slug: string): Promise<{ d: ConstellationDetail; generatedAt: string } | null> {18 try {19 const res = await api.constellation(slug);20 return { d: res.data, generatedAt: res.meta.generated_at };21 } catch (e) {22 if (e instanceof ApiError && e.notFound) return null;23 throw e;24 }25}2627export async function generateMetadata({ params }: Props): Promise<Metadata> {28 const { slug } = await params;29 const r = await load(slug).catch(() => null);30 if (!r) return { title: 'Constellation not found', robots: { index: false } };31 const { d } = r;32 const desc = `${d.name}${d.operator_name ? ` (${d.operator_name})` : ''}: ${fmtInt(d.active)} active satellites, ${fmtInt(d.total)} launched since ${fmtDate(d.first_launch)}. ${d.orbit_class ?? ''} ${titleCase(d.service_type)} constellation — deployment timeline, orbital shells, launch history and status.`;33 return entityMetadata({ title: `${d.name} — ${fmtInt(d.active)} active satellites, ${d.orbit_class ?? 'mixed'} constellation`, description: desc, path: routes.constellation(d.slug), ogImage: `${SITE_URL}${routes.constellation(d.slug)}/opengraph-image` });34}3536export default async function ConstellationPage({ params }: Props) {37 const { slug } = await params;38 const r = await load(slug);39 if (!r) notFound();40 const { d, generatedAt } = r;41 const active = num(d.active);42 const planned = d.planned_count;43 const plannedPct = planned && active !== null ? (active / planned) * 100 : null;4445 const jsonLd = {46 '@context': 'https://schema.org',47 '@type': 'Dataset',48 name: `${d.name} constellation`,49 description: d.description ?? `${d.name} satellite constellation tracked by SatelliteIndex`,50 url: `${SITE_URL}${routes.constellation(d.slug)}`,51 creator: d.operator_name ? { '@type': 'Organization', name: d.operator_name, url: d.operator_slug ? `${SITE_URL}${routes.operator(d.operator_slug)}` : undefined } : undefined,52 variableMeasured: ['active satellites', 'satellites on orbit', 'launches'],53 };5455 return (56 <Container wide>57 <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />5859 {/* Hero */}60 <header className="pb-6 pt-8 md:pb-8 md:pt-12">61 <nav aria-label="Breadcrumb" className="eyebrow">62 <Link href={routes.constellations()} className="hover:text-ink">Constellations</Link> <span aria-hidden>/</span> {d.name}63 </nav>64 <div className="mt-3 flex flex-wrap items-center gap-2">65 <OrbitBadge orbitClass={d.orbit_class} />66 {d.service_type && <Tag>{titleCase(d.service_type)}</Tag>}67 <Tag tone={d.lifecycle_stage === 'OPERATIONAL' ? 'accent' : 'warn'}>{titleCase(d.lifecycle_stage.toLowerCase())}</Tag>68 </div>69 <h1 className="display mt-3 text-3xl md:text-5xl">{d.name}</h1>70 <p className="mt-3 max-w-2xl text-[15px] text-ink-2 md:text-base">71 {d.operator_slug ? <Link href={routes.operator(d.operator_slug)} className="link">{d.operator_name}</Link> : d.operator_name ?? 'Operator unavailable'}72 {d.country_slug && (73 <>74 {' '}· <Link href={routes.country(d.country_slug)} className="link">{d.country_name}</Link>75 </>76 )}77 </p>78 {d.description && <p className="mt-4 max-w-3xl text-[15px] leading-relaxed text-ink-2">{d.description}</p>}79 <HeroFacts80 items={[81 { label: 'Official site', value: d.official_url ? <ExternalLink href={d.official_url} /> : null },82 { label: 'Deployment vs plan', value: planned ? `${fmtInt(active)} of ${fmtInt(planned)} planned (${fmtPct(plannedPct, 0)})` : null },83 { label: 'Authorized', value: d.authorized_count ? `${fmtInt(d.authorized_count)} satellites` : null },84 { label: 'Median orbit', value: num(d.median_perigee_km) !== null ? `${fmtInt(d.median_perigee_km)} km · ${fmt1(d.median_inclination_deg)}°` : null },85 ]}86 />87 </header>8889 <KpiStrip90 items={[91 { label: 'Active', value: <span className="text-active">{fmtInt(d.active)}</span> },92 { label: 'Inactive', value: fmtInt(d.inactive) },93 { label: 'Decayed', value: fmtInt(d.decayed) },94 { label: 'On orbit', value: fmtInt(d.on_orbit) },95 { label: 'Total launched', value: fmtInt(d.total) },96 { label: 'Launches', value: fmtInt(d.launches) },97 { label: 'Launched 30 d', value: fmtInt(d.launched_last_30d) },98 { label: 'Launched 365 d', value: fmtInt(d.launched_last_365d) },99 { label: 'First launch', value: <span className="text-xl md:text-2xl">{fmtDate(d.first_launch)}</span> },100 { label: 'Last launch', value: <span className="text-xl md:text-2xl">{fmtDate(d.last_launch)}</span> },101 { label: 'Activity score', value: <span className="text-accent-2">{fmt1(d.activity_score)}</span>, derived: true, hint: 'launch cadence index' },102 { label: 'Snapshot', value: <span className="text-base text-ink-2 md:text-lg">{fmtDateTime(generatedAt)}</span> },103 ]}104 />105106 {/* Terminal layout: main analysis + right telemetry column */}107 <div className="grid gap-x-10 lg:grid-cols-[minmax(0,7fr)_minmax(0,4fr)]">108 <div className="min-w-0">109 <Block eyebrow="Deployment" title="Deployment timeline" id="timeline">110 <DeploymentTimeline growth={d.growth} />111 </Block>112 <Block eyebrow="Orbits" title="Orbital shells" id="shells">113 <OrbitalShells d={d} />114 </Block>115 <Block eyebrow="Launches" title={`Launch history · ${fmtInt(d.launches)} launches`} id="launches">116 <LaunchesTable rows={d.launches_list} showActive />117 {d.launches_list.length < (num(d.launches) ?? 0) && <p className="mt-2 text-xs text-ink-3">Showing the {fmtInt(d.launches_list.length)} most recent launches.</p>}118 </Block>119 <Block eyebrow="Fleet" title="Recent satellites" id="satellites" action={{ href: routes.satellites(`constellation=${encodeURIComponent(d.slug)}`), label: 'All satellites in this constellation' }}>120 <SatellitesTable rows={d.recent_satellites} columns={['perigee', 'apogee', 'inclination']} />121 </Block>122 {d.decays_by_month.length > 0 && (123 <Block eyebrow="Reentries" title="Decays by month" id="decays">124 <DecaysByMonth rows={d.decays_by_month} />125 </Block>126 )}127 <Block eyebrow="Timeline" title="Events" id="events" action={{ href: routes.events(`entity=${encodeURIComponent(d.id)}`), label: 'All events' }}>128 <EventsList events={d.events} />129 </Block>130 </div>131132 <aside className="min-w-0 lg:border-l lg:border-rule lg:pl-10">133 <Block eyebrow="Status" title="Status distribution">134 <StatusDonut dist={d.status_distribution} total={num(d.total)} />135 </Block>136 <Block eyebrow="Ground" title="Launch sites">137 <SitesTable rows={d.launch_sites} showSatellites />138 </Block>139 <Block eyebrow="Registry" title="Countries">140 {d.countries.length === 0 ? (141 <Unavailable what="Country attribution" compact />142 ) : (143 <ul className="divide-y divide-rule text-sm">144 {d.countries.map((c) => (145 <li key={c.code} className="flex items-center justify-between gap-3 py-2">146 <Link href={routes.country(c.slug)} className="link">{c.name} <span className="mono ml-1 text-xs text-ink-3">{c.code}</span></Link>147 <span className="tnum">{fmtInt(c.satellites)}</span>148 </li>149 ))}150 </ul>151 )}152 </Block>153 <Block eyebrow="Regulatory" title="Regulatory filings">154 <PlannedUnavailable what="Regulatory filings" note="FCC / ITU connectors are planned; filings will appear here with source attribution once ingested." />155 </Block>156 <Block eyebrow="Methodology" title="How membership is determined">157 <MembershipNote d={d} />158 </Block>159 </aside>160 </div>161 </Container>162 );163}164